Skip to content

feat(auth): add tab-based PKCE for Firefox mobile support (Phase 4)#271

Merged
rosscado merged 2 commits intomainfrom
feat/phase4-mobile-pkce
Jan 2, 2026
Merged

feat(auth): add tab-based PKCE for Firefox mobile support (Phase 4)#271
rosscado merged 2 commits intomainfrom
feat/phase4-mobile-pkce

Conversation

@rosscado
Copy link
Copy Markdown
Contributor

@rosscado rosscado commented Jan 2, 2026

Summary

Implements Phase 4 (Mobile Optimization) of the authentication flow PRD, adding OAuth 2.1 + PKCE support for Firefox (desktop and Android) using a tab-based flow.

Key Changes

  • New: TabBasedPKCEAuth.ts - Tab-based PKCE authentication flow for Firefox
  • Update: OAuthService.ts - Routes Firefox to tab-based PKCE instead of cookie-based fallback
  • Update: UserAgentModule.ts - Add isFirefoxAndroid() and isKiwiBrowser() detection
  • Update: PKCE_AUTH_SPEC.md - Document Firefox implementation and redirect URI

How Tab-Based PKCE Works

  1. Opens OAuth authorization URL in a new browser tab
  2. Monitors tab URL changes via browser.tabs.onUpdated
  3. Detects redirect to extensions.allizom.org (Firefox redirect URI)
  4. Extracts authorization code from URL parameters
  5. Exchanges code for tokens using PKCE code_verifier
  6. Closes the auth tab automatically

Platform Support

Platform Auth Method Status
Chrome Desktop Identity API Existing
Firefox Desktop Tab-based PKCE New
Firefox Android Tab-based PKCE New
Kiwi Browser Identity API (expected) Needs testing

Server-Side Requirement

Action needed: Register the Firefox redirect URI on the server:
https://gecko@saypi.ai.extensions.allizom.org/

Without this, Firefox PKCE authentication will fail with an unregistered redirect URI error.

Test plan

  • Unit tests for TabBasedPKCEAuth.ts (14 tests passing)
  • Unit tests for isFirefoxAndroid() and isKiwiBrowser() (6 new tests)
  • Full test suite passes (694 tests)
  • Build succeeds
  • Manual testing on Firefox Desktop (after server-side URI registration)
  • Manual testing on Firefox Android
  • Verify Kiwi Browser uses identity API correctly

Generated with Claude Code

Implements OAuth 2.1 + PKCE authentication for Firefox (desktop and Android)
using a tab-based flow that intercepts OAuth redirects via browser.tabs.onUpdated.

Key changes:
- Add TabBasedPKCEAuth.ts with tab-based PKCE flow for Firefox
- Update OAuthService.ts to route Firefox to tab-based PKCE
- Add isFirefoxAndroid() and isKiwiBrowser() platform detection
- Update PKCE_AUTH_SPEC.md with Firefox implementation details

The tab-based flow:
1. Opens OAuth authorization URL in a new browser tab
2. Monitors tab URL changes via browser.tabs.onUpdated
3. Detects redirect to extensions.allizom.org
4. Extracts authorization code and exchanges for tokens
5. Closes the auth tab automatically

Server requirement: Register Firefox redirect URI to enable PKCE authentication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@rosscado rosscado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Tab-Based PKCE Authentication (Phase 4)

I've reviewed the implementation and found 3 critical bugs and 2 high-priority issues that should be addressed before merging.


🔴 Critical Bug #1: Memory Leak - removeListener Never Cleaned Up

File: src/auth/TabBasedPKCEAuth.ts lines 319-326, 178-195

The removeListener callback added to browser.tabs.onRemoved at line 326 is never removed when authentication succeeds.

// Line 326: Listener is added
const removeListener = (tabId: number) => { ... };
browser.tabs.onRemoved.addListener(removeListener);

// Line 178-195: cleanup() removes tabListener but NOT removeListener
const cleanup = async () => {
  if (tabListener) {
    browser.tabs.onUpdated.removeListener(tabListener);  // ✅ Good
    tabListener = undefined;
  }
  // ❌ MISSING: browser.tabs.onRemoved.removeListener(removeListener);
};

Impact: Every successful auth leaves a dangling listener that continues to fire on all future tab closures.

Fix: Add removeListener to the cleanup() function or track it as a variable like tabListener.


🔴 Critical Bug #2: Race Condition - Listener Registered Before authTabId Set

File: src/auth/TabBasedPKCEAuth.ts lines 319-331

// Line 326: Listener added HERE - authTabId is still undefined
browser.tabs.onRemoved.addListener(removeListener);

// Lines 329-331: Tab created and authTabId assigned HERE - AFTER listener
const tab = await browser.tabs.create({ url: authUrl, active: true });
authTabId = tab.id;

Issue: Between lines 326-331, if any tab is closed (not related to auth), removeListener executes with authTabId === undefined. Since the check tabId === authTabId will fail (comparing to undefined), it passes without cleanup. However, the listener remains forever active.

Fix: Move addListener after authTabId is assigned, or add a guard: if (authTabId === undefined) return;


🔴 Critical Bug #3: cleanup() Not Awaited in Synchronous Callbacks

File: src/auth/TabBasedPKCEAuth.ts lines 236-243, 247-254, 294-302, 322-324

The tabListener and removeListener are synchronous callbacks, but they call cleanup() (an async function) without awaiting:

// Lines 236-243: cleanup() not awaited
cleanup();         // ❌ Returns immediately
clearPKCEState();  // ❌ Races with cleanup()
resolve({...});    // ❌ Resolves before tab is actually closed

Impact: The promise resolves before browser.tabs.remove() completes, and clearPKCEState() may race with storage operations inside cleanup().

Fix: Either make cleanup synchronous (remove the await inside) or restructure to handle async properly. Consider using a flag pattern like JwtManager.ts uses with isClearing.


🟡 High Priority: Concurrent Auth Call Vulnerability

File: src/auth/TabBasedPKCEAuth.ts lines 161-356

If authenticateWithTabBasedPKCE() is called multiple times concurrently (e.g., user rapid-clicks "Sign In"), each call registers its own listeners. Only the first call's cleanup will run, leaving others orphaned.

Fix: Add a guard flag at module scope to prevent concurrent auth attempts:

let authInProgress = false;

export async function authenticateWithTabBasedPKCE(): Promise<OAuthResult> {
  if (authInProgress) {
    return { success: false, error: 'auth_in_progress', errorDescription: 'Authentication already in progress' };
  }
  authInProgress = true;
  try {
    // ... existing code
  } finally {
    authInProgress = false;
  }
}

🟡 High Priority: removeListener Self-Removes But May Leave Artifacts

File: src/auth/TabBasedPKCEAuth.ts lines 319-325

const removeListener = (tabId: number) => {
  if (tabId === authTabId) {
    browser.tabs.onRemoved.removeListener(removeListener);  // Self-removes
    cleanup();
    clearPKCEState();
    // ❌ But never resolves the authPromise - leaves it hanging on timeout
  }
};

When user closes the auth tab, the removeListener fires and cleans up, but the authPromise is never resolved/rejected directly. It will eventually timeout after 5 minutes.

Fix: Add resolve() call in removeListener with appropriate error response.


✅ What Looks Good

  • PKCE flow logic is correct
  • State validation and CSRF protection are properly implemented
  • Token exchange follows OAuth 2.1 spec
  • Test coverage is comprehensive (14 tests)
  • User agent detection functions are well-implemented

Summary

# Severity Issue Location
1 🔴 Critical Memory leak - removeListener never cleaned up L319-326, L178-195
2 🔴 Critical Race condition - listener before authTabId L319-331
3 🔴 Critical Async cleanup not awaited L236-243, L247-254, etc
4 🟡 High Concurrent auth calls vulnerability L161-356
5 🟡 High User closes tab - promise hangs L319-325

🤖 Generated with Claude Code

Fixes 5 issues identified in code review:

1. Memory leak - `removeListener` now tracked and cleaned up
   - Added `removeListener` variable to cleanup scope
   - cleanup() now removes both onUpdated and onRemoved listeners

2. Race condition - listeners now registered AFTER tab creation
   - Moved browser.tabs.create() before listener registration
   - Added guard checks for undefined authTabId in listeners

3. Async cleanup - made cleanup synchronous where needed
   - Changed cleanup() to synchronous function
   - Listener removal is immediate; tab removal is fire-and-forget
   - Added isCleanedUp guard flag to prevent multiple cleanups

4. Concurrent auth vulnerability - added authInProgress guard
   - Module-level flag prevents overlapping auth attempts
   - Returns 'auth_in_progress' error if already authenticating

5. Promise hanging when user closes tab - removeListener now resolves
   - removeListener callback now resolves promise with 'auth_cancelled'
   - Logs when user closes auth tab directly

Added 5 new tests:
- Concurrent auth prevention
- User closing auth tab directly
- removeListener cleanup verification
- Auth allowed after previous completes
- Listeners registered after tab creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@rosscado
Copy link
Copy Markdown
Contributor Author

rosscado commented Jan 2, 2026

✅ Code Review Issues Resolved

All 5 issues from the code review have been addressed in commit cf1ad22:

Fixes Applied

Issue Fix
🔴 Memory leak - removeListener never cleaned up Now tracked as a variable and removed in cleanup()
🔴 Race condition - listener before authTabId Listeners now registered AFTER browser.tabs.create() returns
🔴 Async cleanup not awaited Made cleanup() synchronous - listener removal is immediate, tab removal is fire-and-forget
🟡 Concurrent auth vulnerability Added authInProgress module-level guard flag
🟡 Promise hanging when user closes tab removeListener now resolves promise with auth_cancelled

New Tests Added

5 new tests verify the fixes:

  1. prevents concurrent authentication attempts
  2. handles user closing auth tab directly
  3. cleans up removeListener on success
  4. allows new auth after previous auth completes
  5. registers listeners only after tab is created

All 699 tests pass ✅

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor Author

@rosscado rosscado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@rosscado rosscado merged commit cccb762 into main Jan 2, 2026
1 check passed
@rosscado rosscado deleted the feat/phase4-mobile-pkce branch January 2, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant